--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit bce42bcdce0e7d2c6d78228e864a4cd1d974cc82
Parents : 291f06d
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-19T08:51:58-05:00
feat: add message maintenance features for purging old messages and clearing duplicates
Changes
26 files changed, 1574 insertions(+), 120 deletions(-)
Diff
diff --git a/meshchatx.rsm b/meshchatx.rsm
index 214a8c88..259c26d4 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 63d526ec..e7e50242 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -177,6 +177,20 @@ from meshchatx.src.backend.message_blocklist import (
parse_import_document,
parse_message_blocklist_json,
)
+from meshchatx.src.backend.auto_resend_guard import (
+ AUTO_RESEND_COOLDOWN_SECONDS,
+ MAX_AUTO_RESEND_ATTEMPTS,
+ RECENT_SAME_CONTENT_SECONDS,
+ AutoResendCoordinator,
+ cooldown_until,
+ fields_with_auto_resend_count,
+ next_attempt_count,
+ should_skip_for_budget,
+)
+from meshchatx.src.backend.local_message_retention import (
+ purge_messages_before_cutoff,
+ resolve_message_age_cutoff,
+)
from meshchatx.src.backend.message_export_bundle import (
build_messages_export_bundle,
import_messages_export_bundle,
@@ -522,6 +536,7 @@ class ReticulumMeshChat:
self.contexts: dict[str, IdentityContext] = {}
self.current_context: IdentityContext | None = None
self._propagation_sync_metrics: dict[str, dict] = {}
+ self._auto_resend_coordinator = AutoResendCoordinator()
AsyncUtils.ensure_background_loop()
self.web_audio_bridge = WebAudioBridge(None, None)
@@ -8956,11 +8971,94 @@ class ReticulumMeshChat:
status=500,
)
- # maintenance - clear messages
+ # maintenance - clear messages (all, or older than days / before date)
@routes.delete("/api/v1/maintenance/messages")
async def maintenance_clear_messages(request):
- self.database.messages.delete_all_lxmf_messages()
- return web.json_response({"message": "All messages cleared"})
+ try:
+ cutoff = resolve_message_age_cutoff(
+ older_than_days=request.query.get("older_than_days"),
+ before=request.query.get("before"),
+ )
+ except ValueError as e:
+ return web.json_response({"message": str(e)}, status=400)
+ if cutoff is None:
+ self.database.messages.delete_all_lxmf_messages()
+ return web.json_response(
+ {"message": "All messages cleared", "deleted": None}
+ )
+
+ def _cancel(h):
+ try:
+ if self.message_router is not None:
+ self.message_router.cancel_outbound(h)
+ except Exception:
+ pass
+
+ deleted = await asyncio.to_thread(
+ purge_messages_before_cutoff,
+ self.database.messages,
+ _cancel,
+ cutoff,
+ )
+ return web.json_response(
+ {
+ "message": f"Deleted {deleted} messages older than cutoff",
+ "deleted": deleted,
+ "cutoff": cutoff,
+ },
+ )
+
+ @routes.get("/api/v1/maintenance/messages/duplicates")
+ async def maintenance_messages_duplicates_preview(request):
+ count = await asyncio.to_thread(
+ self.database.messages.count_duplicate_lxmf_messages_by_content,
+ )
+ return web.json_response({"count": count})
+
+ @routes.delete("/api/v1/maintenance/messages/duplicates")
+ async def maintenance_messages_duplicates_clear(request):
+ def _clear():
+ hashes = self.database.messages.list_duplicate_lxmf_message_hashes_by_content()
+ if not hashes:
+ return 0
+ if self.message_router is not None:
+ for h in hashes:
+ if not h or len(h) % 2 != 0:
+ continue
+ try:
+ self.message_router.cancel_outbound(bytes.fromhex(h))
+ except Exception:
+ pass
+ self.database.messages.delete_lxmf_messages_by_hashes(hashes)
+ self.database.messages.prune_conversation_metadata_for_peers_with_no_messages()
+ return len(hashes)
+
+ deleted = await asyncio.to_thread(_clear)
+ return web.json_response(
+ {
+ "message": f"Deleted {deleted} duplicate messages",
+ "deleted": deleted,
+ },
+ )
+
+ @routes.get("/api/v1/maintenance/messages/purge-preview")
+ async def maintenance_messages_purge_preview(request):
+ try:
+ cutoff = resolve_message_age_cutoff(
+ older_than_days=request.query.get("older_than_days"),
+ before=request.query.get("before"),
+ )
+ except ValueError as e:
+ return web.json_response({"message": str(e)}, status=400)
+ if cutoff is None:
+ return web.json_response(
+ {"message": "older_than_days or before is required"},
+ status=400,
+ )
+ count = self.database.messages.count_lxmf_messages_with_timestamp_before(
+ cutoff,
+ )
+ return web.json_response({"count": count, "cutoff": cutoff})
# maintenance - clear announces
@routes.delete("/api/v1/maintenance/announces")
@@ -9018,17 +9116,33 @@ class ReticulumMeshChat:
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
- # maintenance - export messages
+ # maintenance - export messages (optional age filter for archive-before-purge)
@routes.get("/api/v1/maintenance/messages/export")
async def maintenance_export_messages(request):
+ try:
+ cutoff = resolve_message_age_cutoff(
+ older_than_days=request.query.get("older_than_days"),
+ before=request.query.get("before"),
+ )
+ except ValueError as e:
+ return web.json_response({"message": str(e)}, status=400)
messages_list = []
page_size = 5000
offset = 0
while True:
- page = self.database.messages.get_all_lxmf_messages(
- limit=page_size,
- offset=offset,
- )
+ if cutoff is None:
+ page = self.database.messages.get_all_lxmf_messages(
+ limit=page_size,
+ offset=offset,
+ )
+ else:
+ page = (
+ self.database.messages.get_lxmf_messages_with_timestamp_before(
+ cutoff,
+ limit=page_size,
+ offset=offset,
+ )
+ )
messages_list.extend(dict(m) for m in page)
if len(page) < page_size:
break
@@ -23020,12 +23134,17 @@ class ReticulumMeshChat:
# resend all failed messages that were intended for this destination
if ctx.config.auto_resend_failed_messages_when_announce_received.get():
- AsyncUtils.run_async(
- self.resend_failed_messages_for_destination(
- destination_hash.hex(),
- context=ctx,
- ),
- )
+ try:
+ path_ready = RNS.Transport.has_path(destination_hash)
+ except Exception:
+ path_ready = False
+ if path_ready:
+ AsyncUtils.run_async(
+ self.resend_failed_messages_for_destination(
+ destination_hash.hex(),
+ context=ctx,
+ ),
+ )
def on_lxmf_propagation_announce_received(
self,
@@ -23091,87 +23210,146 @@ class ReticulumMeshChat:
if not ctx:
return
- # get messages that failed to send to this destination
- failed_messages = ctx.database.messages.get_failed_messages_for_destination(
- destination_hash,
- )
+ identity_key = ""
+ try:
+ if ctx.identity is not None and getattr(ctx.identity, "hash", None):
+ identity_key = ctx.identity.hash.hex()
+ except Exception:
+ identity_key = destination_hash
- # resend failed messages
- for failed_message in failed_messages:
- try:
- # parse fields as json
- fields = json.loads(failed_message["fields"])
+ lock = self._auto_resend_coordinator.lock_for(identity_key, destination_hash)
+ async with lock:
+ failed_messages = ctx.database.messages.get_failed_messages_for_destination(
+ destination_hash,
+ )
+ now = time.time()
- # parse image field
- image_field = None
- if "image" in fields:
- image_field = LxmfImageField(
- fields["image"]["image_type"],
- base64.b64decode(fields["image"]["image_bytes"]),
- )
+ for failed_message in failed_messages:
+ try:
+ message_hash = failed_message.get("hash")
+ if not message_hash:
+ continue
+
+ if should_skip_for_budget(
+ failed_message.get("fields"),
+ max_attempts=MAX_AUTO_RESEND_ATTEMPTS,
+ ):
+ continue
+
+ if ctx.database.messages.has_recent_outbound_with_content(
+ destination_hash,
+ failed_message.get("content"),
+ within_seconds=RECENT_SAME_CONTENT_SECONDS,
+ now=now,
+ ):
+ continue
- # parse audio field
- audio_field = None
- if "audio" in fields:
- audio_field = LxmfAudioField(
- fields["audio"]["audio_mode"],
- base64.b64decode(fields["audio"]["audio_bytes"]),
+ claimed = (
+ ctx.database.messages.try_claim_failed_message_for_auto_resend(
+ message_hash,
+ cooldown_until=cooldown_until(
+ now,
+ seconds=AUTO_RESEND_COOLDOWN_SECONDS,
+ ),
+ now=now,
+ )
)
+ if not claimed:
+ continue
- # parse file attachments field
- file_attachments_field = None
- if "file_attachments" in fields:
- file_attachments = [
- LxmfFileAttachment(
- file_attachment["file_name"],
- base64.b64decode(file_attachment["file_bytes"]),
+ # parse fields as json
+ fields = json.loads(failed_message["fields"] or "{}")
+ if not isinstance(fields, dict):
+ fields = {}
+
+ # parse image field
+ image_field = None
+ if "image" in fields:
+ image_field = LxmfImageField(
+ fields["image"]["image_type"],
+ base64.b64decode(fields["image"]["image_bytes"]),
)
- for file_attachment in fields["file_attachments"]
- ]
- file_attachments_field = LxmfFileAttachmentsField(file_attachments)
- # don't resend message with attachments if not allowed
- if not ctx.config.allow_auto_resending_failed_messages_with_attachments.get():
+ # parse audio field
+ audio_field = None
+ if "audio" in fields:
+ audio_field = LxmfAudioField(
+ fields["audio"]["audio_mode"],
+ base64.b64decode(fields["audio"]["audio_bytes"]),
+ )
+
+ # parse file attachments field
+ file_attachments_field = None
+ if "file_attachments" in fields:
+ file_attachments = [
+ LxmfFileAttachment(
+ file_attachment["file_name"],
+ base64.b64decode(file_attachment["file_bytes"]),
+ )
+ for file_attachment in fields["file_attachments"]
+ ]
+ file_attachments_field = LxmfFileAttachmentsField(
+ file_attachments,
+ )
+
+ # don't resend message with attachments if not allowed
+ if not ctx.config.allow_auto_resending_failed_messages_with_attachments.get():
+ if (
+ image_field is not None
+ or audio_field is not None
+ or file_attachments_field is not None
+ ):
+ print(
+ "Not resending failed message with attachments, as setting is disabled",
+ )
+ continue
+
+ attempt = next_attempt_count(failed_message.get("fields"))
+ ctx.database.messages.set_message_fields_json(
+ message_hash,
+ fields_with_auto_resend_count(
+ failed_message.get("fields"),
+ attempt,
+ ),
+ )
+
+ # send new message with failed message content
+ new_message = await self.send_message(
+ failed_message["destination_hash"],
+ failed_message["content"],
+ image_field=image_field,
+ audio_field=audio_field,
+ file_attachments_field=file_attachments_field,
+ context=ctx,
+ )
+
+ # Only drop the old failed row after a replacement was queued.
if (
- image_field is not None
- or audio_field is not None
- or file_attachments_field is not None
+ new_message is None
+ or getattr(new_message, "hash", None) is None
):
- print(
- "Not resending failed message with attachments, as setting is disabled",
- )
continue
- # send new message with failed message content
- new_message = await self.send_message(
- failed_message["destination_hash"],
- failed_message["content"],
- image_field=image_field,
- audio_field=audio_field,
- file_attachments_field=file_attachments_field,
- context=ctx,
- )
-
- # Only drop the old failed row after a replacement was queued.
- if new_message is None or getattr(new_message, "hash", None) is None:
- continue
+ new_hash = new_message.hash.hex()
+ ctx.database.messages.set_auto_resend_count_on_message(
+ new_hash,
+ attempt,
+ )
- ctx.database.messages.delete_lxmf_message_by_hash(
- failed_message["hash"],
- )
+ ctx.database.messages.delete_lxmf_message_by_hash(message_hash)
- # tell all websocket clients that old failed message was deleted so it can remove from ui
- await self.websocket_broadcast(
- json.dumps(
- {
- "type": "lxmf_message_deleted",
- "hash": failed_message["hash"],
- },
- ),
- )
+ # tell all websocket clients that old failed message was deleted so it can remove from ui
+ await self.websocket_broadcast(
+ json.dumps(
+ {
+ "type": "lxmf_message_deleted",
+ "hash": message_hash,
+ },
+ ),
+ )
- except Exception as e:
- print("Error resending failed message: " + str(e))
+ except Exception as e:
+ print("Error resending failed message: " + str(e))
def on_rrc_hub_announce_received(
self,
diff --git a/meshchatx/src/backend/auto_resend_guard.py b/meshchatx/src/backend/auto_resend_guard.py
new file mode 100644
index 00000000..611035d3
--- /dev/null
+++ b/meshchatx/src/backend/auto_resend_guard.py
@@ -0,0 +1,83 @@
+# SPDX-License-Identifier: 0BSD
+"""Guards for auto-resending failed LXMF messages without flooding peers."""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import json
+import time
+from typing import Any
+
+# Max automatic replacement sends per failed lineage before requiring manual retry.
+MAX_AUTO_RESEND_ATTEMPTS = 3
+# Minimum seconds between auto-resend claims for the same failed row.
+AUTO_RESEND_COOLDOWN_SECONDS = 120
+# Skip auto-resend when a recent outbound with the same body already exists.
+RECENT_SAME_CONTENT_SECONDS = 300
+
+AUTO_RESEND_COUNT_FIELD = "_mcx_auto_resend_count"
+
+
+def content_fingerprint(content: str | None) -> str:
+ raw = (content or "").encode("utf-8", errors="replace")
+ return hashlib.sha256(raw).hexdigest()
+
+
+def read_auto_resend_count(fields_raw: Any) -> int:
+ fields = _parse_fields(fields_raw)
+ try:
+ return max(0, int(fields.get(AUTO_RESEND_COUNT_FIELD, 0)))
+ except (TypeError, ValueError):
+ return 0
+
+
+def fields_with_auto_resend_count(fields_raw: Any, count: int) -> str:
+ fields = _parse_fields(fields_raw)
+ fields[AUTO_RESEND_COUNT_FIELD] = max(0, int(count))
+ return json.dumps(fields)
+
+
+def _parse_fields(fields_raw: Any) -> dict:
+ if isinstance(fields_raw, dict):
+ return dict(fields_raw)
+ if not fields_raw:
+ return {}
+ if isinstance(fields_raw, str):
+ try:
+ parsed = json.loads(fields_raw)
+ except (TypeError, ValueError, json.JSONDecodeError):
+ return {}
+ return parsed if isinstance(parsed, dict) else {}
+ return {}
+
+
+class AutoResendCoordinator:
+ """Per destination asyncio lock so announce/ping/path cannot race-resend."""
+
+ def __init__(self) -> None:
+ self._locks: dict[str, asyncio.Lock] = {}
+
+ def lock_for(self, identity_key: str, destination_hash: str) -> asyncio.Lock:
+ key = f"{identity_key}:{destination_hash}"
+ lock = self._locks.get(key)
+ if lock is None:
+ lock = asyncio.Lock()
+ self._locks[key] = lock
+ return lock
+
+
+def should_skip_for_budget(
+ fields_raw: Any, *, max_attempts: int = MAX_AUTO_RESEND_ATTEMPTS
+) -> bool:
+ return read_auto_resend_count(fields_raw) >= max_attempts
+
+
+def next_attempt_count(fields_raw: Any) -> int:
+ return read_auto_resend_count(fields_raw) + 1
+
+
+def cooldown_until(
+ now: float | None = None, *, seconds: int = AUTO_RESEND_COOLDOWN_SECONDS
+) -> float:
+ return float(now if now is not None else time.time()) + float(seconds)
diff --git a/meshchatx/src/backend/database/messages.py b/meshchatx/src/backend/database/messages.py
index 35aa3e4a..76185a7e 100644
--- a/meshchatx/src/backend/database/messages.py
+++ b/meshchatx/src/backend/database/messages.py
@@ -506,6 +506,27 @@ class MessageDAO:
)
return [r["hash"] for r in rows if r.get("hash")]
+ def count_lxmf_messages_with_timestamp_before(self, cutoff_ts: float) -> int:
+ row = self.provider.fetchone(
+ "SELECT COUNT(*) AS count FROM lxmf_messages "
+ "WHERE timestamp IS NOT NULL AND timestamp < ?",
+ (cutoff_ts,),
+ )
+ return int(row["count"]) if row and row["count"] is not None else 0
+
+ def get_lxmf_messages_with_timestamp_before(
+ self,
+ cutoff_ts: float,
+ limit: int = 5000,
+ offset: int = 0,
+ ):
+ return self.provider.fetchall(
+ "SELECT * FROM lxmf_messages "
+ "WHERE timestamp IS NOT NULL AND timestamp < ? "
+ "ORDER BY id LIMIT ? OFFSET ?",
+ (cutoff_ts, limit, offset),
+ )
+
def prune_conversation_metadata_for_peers_with_no_messages(self) -> None:
self.provider.execute(
"""
@@ -827,12 +848,128 @@ class MessageDAO:
(datetime.now(UTC).isoformat(),),
)
+ def list_duplicate_lxmf_message_hashes_by_content(self) -> list[str]:
+ """Hashes of duplicate rows (same peer, direction, and text), excluding the oldest keep.
+
+ Empty or whitespace-only content is ignored so attachment-only or blank
+ rows are not collapsed together.
+ """
+ rows = self.provider.fetchall(
+ """
+ SELECT m.hash AS hash
+ FROM lxmf_messages m
+ INNER JOIN (
+ SELECT peer_hash, is_incoming, content, MIN(id) AS keep_id
+ FROM lxmf_messages
+ WHERE content IS NOT NULL AND TRIM(content) != ''
+ GROUP BY peer_hash, is_incoming, content
+ HAVING COUNT(*) > 1
+ ) d
+ ON m.peer_hash = d.peer_hash
+ AND m.is_incoming = d.is_incoming
+ AND m.content = d.content
+ WHERE m.id != d.keep_id
+ """,
+ )
+ return [r["hash"] for r in rows if r.get("hash")]
+
+ def count_duplicate_lxmf_messages_by_content(self) -> int:
+ return len(self.list_duplicate_lxmf_message_hashes_by_content())
+
+ def delete_duplicate_lxmf_messages_by_content(self) -> int:
+ """Delete content-duplicate message rows, keeping the oldest per group."""
+ hashes = self.list_duplicate_lxmf_message_hashes_by_content()
+ if not hashes:
+ return 0
+ self.delete_lxmf_messages_by_hashes(hashes)
+ self.prune_conversation_metadata_for_peers_with_no_messages()
+ return len(hashes)
+
def get_failed_messages_for_destination(self, destination_hash):
return self.provider.fetchall(
"SELECT * FROM lxmf_messages WHERE state = 'failed' AND peer_hash = ? ORDER BY id ASC",
(destination_hash,),
)
+ def try_claim_failed_message_for_auto_resend(
+ self,
+ message_hash: str,
+ *,
+ cooldown_until: float,
+ now: float,
+ ) -> bool:
+ """Atomically claim a failed row for one auto-resend attempt.
+
+ Sets next_delivery_attempt_at into the future so overlapping announce,
+ ping, and path handlers cannot claim the same row again until cooldown.
+ """
+ now_iso = datetime.now(UTC).isoformat()
+ cursor = self.provider.execute(
+ """
+ UPDATE lxmf_messages
+ SET next_delivery_attempt_at = ?, updated_at = ?
+ WHERE hash = ?
+ AND state = 'failed'
+ AND (
+ next_delivery_attempt_at IS NULL
+ OR next_delivery_attempt_at <= ?
+ )
+ """,
+ (float(cooldown_until), now_iso, message_hash, float(now)),
+ )
+ return bool(cursor and cursor.rowcount and cursor.rowcount > 0)
+
+ def set_message_fields_json(self, message_hash: str, fields_json: str) -> None:
+ now_iso = datetime.now(UTC).isoformat()
+ self.provider.execute(
+ "UPDATE lxmf_messages SET fields = ?, updated_at = ? WHERE hash = ?",
+ (fields_json, now_iso, message_hash),
+ )
+
+ def set_auto_resend_count_on_message(self, message_hash: str, count: int) -> None:
+ from meshchatx.src.backend.auto_resend_guard import (
+ fields_with_auto_resend_count,
+ )
+
+ row = self.provider.fetchone(
+ "SELECT fields FROM lxmf_messages WHERE hash = ?",
+ (message_hash,),
+ )
+ if not row:
+ return
+ self.set_message_fields_json(
+ message_hash,
+ fields_with_auto_resend_count(row.get("fields"), count),
+ )
+
+ def has_recent_outbound_with_content(
+ self,
+ peer_hash: str,
+ content: str | None,
+ *,
+ within_seconds: float,
+ now: float | None = None,
+ ) -> bool:
+ """True when a recent non-failed outbound already carries the same body."""
+ import time as _time
+
+ now_ts = float(now if now is not None else _time.time())
+ cutoff = now_ts - float(within_seconds)
+ row = self.provider.fetchone(
+ """
+ SELECT 1 AS ok FROM lxmf_messages
+ WHERE peer_hash = ?
+ AND is_incoming = 0
+ AND state != 'failed'
+ AND content = ?
+ AND timestamp IS NOT NULL
+ AND timestamp >= ?
+ LIMIT 1
+ """,
+ (peer_hash, content if content is not None else "", cutoff),
+ )
+ return bool(row)
+
def get_failed_messages_count(self, destination_hash):
row = self.provider.fetchone(
"SELECT COUNT(*) as count FROM lxmf_messages WHERE state = 'failed' AND peer_hash = ?",
diff --git a/meshchatx/src/backend/local_message_retention.py b/meshchatx/src/backend/local_message_retention.py
index 6670fb8d..da641cfb 100644
--- a/meshchatx/src/backend/local_message_retention.py
+++ b/meshchatx/src/backend/local_message_retention.py
@@ -1,8 +1,12 @@
# SPDX-License-Identifier: 0BSD
"""Local-only age-based deletion of stored LXMF rows (this device, no network signal)."""
+from __future__ import annotations
+
import logging
+import time
from collections.abc import Callable
+from datetime import UTC, date, datetime
log = logging.getLogger(__name__)
@@ -41,20 +45,77 @@ def local_message_retention_cutoff_ts(now: float, value: int, unit: str) -> floa
return float(now) - float(retention_window_seconds(value, unit))
-def apply_local_message_retention(
+def parse_before_cutoff(raw: str | None) -> float | None:
+ """Parse a before cutoff from unix seconds or an ISO date/datetime string.
+
+ YYYY-MM-DD is treated as midnight UTC on that calendar day (messages with
+ timestamp strictly before that instant).
+ """
+ if raw is None:
+ return None
+ s = str(raw).strip()
+ if not s:
+ return None
+ try:
+ return float(s)
+ except (TypeError, ValueError):
+ pass
+ try:
+ if len(s) == 10 and s[4] == "-" and s[7] == "-":
+ d = date.fromisoformat(s)
+ return datetime(d.year, d.month, d.day, tzinfo=UTC).timestamp()
+ dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=UTC)
+ return dt.timestamp()
+ except ValueError as exc:
+ raise ValueError("invalid before value") from exc
+
+
+def resolve_message_age_cutoff(
+ *,
+ older_than_days: str | int | None = None,
+ before: str | None = None,
+ now: float | None = None,
+) -> float | None:
+ """Resolve a purge/export cutoff from query-style inputs.
+
+ Returns None when neither filter is provided. Prefer ``before`` when both
+ are set. Raises ValueError on invalid input.
+ """
+ before_raw = None if before is None else str(before).strip()
+ if before_raw:
+ cutoff = parse_before_cutoff(before_raw)
+ if cutoff is None:
+ raise ValueError("invalid before value")
+ return float(cutoff)
+
+ if older_than_days is None or str(older_than_days).strip() == "":
+ return None
+ try:
+ days = int(older_than_days)
+ except (TypeError, ValueError) as exc:
+ raise ValueError("older_than_days must be an integer") from exc
+ if days < 1 or days > MAX_VALUE_DAYS:
+ raise ValueError(f"older_than_days must be between 1 and {MAX_VALUE_DAYS}")
+ return local_message_retention_cutoff_ts(
+ now if now is not None else time.time(),
+ days,
+ UNIT_DAYS,
+ )
+
+
+def purge_messages_before_cutoff(
messages,
cancel_outbound: Callable[[bytes], None] | None,
- *,
- value: int,
- unit: str,
- now: float,
+ cutoff: float,
) -> int:
- """Delete local LXMF message rows older than the retention window.
+ """Delete local LXMF rows with timestamp strictly before cutoff.
- Does not contact peers; only removes rows from the local database.
+ Attachments live in message row fields, so deleting the row removes them.
+ Does not contact peers.
"""
- cutoff = local_message_retention_cutoff_ts(now, value, unit)
- hashes = messages.list_message_hashes_with_timestamp_before(cutoff)
+ hashes = messages.list_message_hashes_with_timestamp_before(float(cutoff))
if not hashes:
return 0
if cancel_outbound is not None:
@@ -64,7 +125,23 @@ def apply_local_message_retention(
try:
cancel_outbound(bytes.fromhex(h))
except Exception as exc: # noqa: BLE001
- log.debug("local_message_retention cancel_outbound: %s", exc)
+ log.debug("purge_messages_before_cutoff cancel_outbound: %s", exc)
messages.delete_lxmf_messages_by_hashes(hashes)
messages.prune_conversation_metadata_for_peers_with_no_messages()
return len(hashes)
+
+
+def apply_local_message_retention(
+ messages,
+ cancel_outbound: Callable[[bytes], None] | None,
+ *,
+ value: int,
+ unit: str,
+ now: float,
+) -> int:
+ """Delete local LXMF message rows older than the retention window.
+
+ Does not contact peers; only removes rows from the local database.
+ """
+ cutoff = local_message_retention_cutoff_ts(now, value, unit)
+ return purge_messages_before_cutoff(messages, cancel_outbound, cutoff)
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index 1b12517a..8e3a783a 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -413,6 +413,113 @@
</div>
</header>
<div class="settings-section__body space-y-4">
+ <div
+ class="rounded-2xl border border-amber-200 dark:border-amber-900/40 bg-amber-50/60 dark:bg-amber-950/20 p-4 space-y-3"
+ >
+ <div>
+ <div class="text-sm font-bold text-gray-900 dark:text-gray-100">
+ {{ $t("maintenance.purge_old_title") }}
+ </div>
+ <div class="text-xs text-gray-600 dark:text-zinc-400 mt-1">
+ {{ $t("maintenance.purge_old_desc") }}
+ </div>
+ </div>
+ <div class="flex flex-wrap gap-2">
+ <button
+ type="button"
+ class="px-3 py-1.5 rounded-lg text-xs font-semibold border transition"
+ :class="
+ messageAgePurgeMode === 'days'
+ ? 'border-amber-500 bg-amber-100 dark:bg-amber-900/40 text-amber-900 dark:text-amber-100'
+ : 'border-gray-200 dark:border-zinc-700 text-gray-700 dark:text-zinc-300'
+ "
+ @click="messageAgePurgeMode = 'days'"
+ >
+ {{ $t("maintenance.purge_mode_days") }}
+ </button>
+ <button
+ type="button"
+ class="px-3 py-1.5 rounded-lg text-xs font-semibold border transition"
+ :class="
+ messageAgePurgeMode === 'date'
+ ? 'border-amber-500 bg-amber-100 dark:bg-amber-900/40 text-amber-900 dark:text-amber-100'
+ : 'border-gray-200 dark:border-zinc-700 text-gray-700 dark:text-zinc-300'
+ "
+ @click="messageAgePurgeMode = 'date'"
+ >
+ {{ $t("maintenance.purge_mode_date") }}
+ </button>
+ </div>
+ <div
+ v-if="messageAgePurgeMode === 'days'"
+ class="flex flex-wrap items-center gap-2"
+ >
+ <label class="text-sm text-gray-800 dark:text-zinc-200" for="purge-older-days">
+ {{ $t("maintenance.purge_older_than_days") }}
+ </label>
+ <input
+ id="purge-older-days"
+ v-model.number="messageAgePurgeDays"
+ type="number"
+ min="1"
+ max="10000"
+ class="input-field w-24"
+ :aria-label="$t('maintenance.purge_older_than_days')"
+ @change="refreshMessageAgePurgePreview"
+ />
+ </div>
+ <div v-else class="flex flex-wrap items-center gap-2">
+ <label class="text-sm text-gray-800 dark:text-zinc-200" for="purge-before-date">
+ {{ $t("maintenance.purge_before_date") }}
+ </label>
+ <input
+ id="purge-before-date"
+ v-model="messageAgePurgeBeforeDate"
+ type="date"
+ class="input-field"
+ :aria-label="$t('maintenance.purge_before_date')"
+ @change="refreshMessageAgePurgePreview"
+ />
+ </div>
+ <div class="text-xs text-gray-600 dark:text-zinc-400">
+ <span v-if="messageAgePurgePreviewLoading">{{
+ $t("maintenance.purge_preview_loading")
+ }}</span>
+ <span v-else-if="messageAgePurgePreviewCount != null">{{
+ $t("maintenance.purge_preview_count", {
+ count: messageAgePurgePreviewCount,
+ })
+ }}</span>
+ <span v-else>{{ $t("maintenance.purge_preview_hint") }}</span>
+ </div>
+ <div class="flex flex-wrap gap-2">
+ <button
+ type="button"
+ class="px-3 py-2 rounded-xl text-sm font-semibold border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-700 disabled:opacity-60"
+ :disabled="messageAgePurgeBusy"
+ @click="refreshMessageAgePurgePreview"
+ >
+ {{ $t("maintenance.purge_preview") }}
+ </button>
+ <button
+ type="button"
+ class="px-3 py-2 rounded-xl text-sm font-semibold border border-blue-300 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/40 text-blue-800 dark:text-blue-200 hover:bg-blue-100 dark:hover:bg-blue-900/40 disabled:opacity-60"
+ :disabled="messageAgePurgeBusy"
+ @click="exportOldMessagesArchive"
+ >
+ {{ $t("maintenance.export_old_archive") }}
+ </button>
+ <button
+ type="button"
+ class="px-3 py-2 rounded-xl text-sm font-semibold border border-red-300 dark:border-red-800 bg-red-600 text-white hover:bg-red-700 disabled:opacity-60"
+ :disabled="messageAgePurgeBusy"
+ @click="purgeOldMessages"
+ >
+ {{ $t("maintenance.purge_old_confirm_btn") }}
+ </button>
+ </div>
+ </div>
+
<div class="grid grid-cols-1 gap-3">
<button
type="button"
@@ -430,6 +537,22 @@
</div>
</button>
+ <button
+ type="button"
+ class="btn-maintenance border-violet-200 dark:border-violet-900/30 text-violet-800 dark:text-violet-200 bg-violet-50 dark:bg-violet-900/10 hover:bg-violet-100 dark:hover:bg-violet-900/20"
+ @click="clearDuplicateMessages"
+ >
+ <div class="flex flex-col items-start text-left">
+ <div class="font-bold flex items-center gap-2">
+ <MaterialDesignIcon icon-name="content-duplicate" class="size-4" />
+ {{ $t("maintenance.clear_duplicates") }}
+ </div>
+ <div class="text-xs opacity-80">
+ {{ $t("maintenance.clear_duplicates_desc") }}
+ </div>
+ </div>
+ </button>
+
<button
type="button"
class="btn-maintenance border-orange-200 dark:border-orange-900/30 text-orange-700 dark:text-orange-300 bg-orange-50 dark:bg-orange-900/10 hover:bg-orange-100 dark:hover:bg-orange-900/20"
@@ -587,6 +710,9 @@
class="size-6 text-blue-500 group-hover:scale-110 transition"
/>
<div class="text-sm font-bold">{{ $t("maintenance.export_messages") }}</div>
+ <div class="text-xs opacity-70 text-center px-1">
+ {{ $t("maintenance.export_messages_desc") }}
+ </div>
</button>
<button
@@ -599,6 +725,9 @@
class="size-6 text-emerald-500 group-hover:scale-110 transition"
/>
<div class="text-sm font-bold">{{ $t("maintenance.import_messages") }}</div>
+ <div class="text-xs opacity-70 text-center px-1">
+ {{ $t("maintenance.import_messages_desc") }}
+ </div>
</button>
<input
ref="importFile"
@@ -3867,6 +3996,12 @@ export default {
shortcuts: [],
reloadingRns: false,
reloadRnsStatusMessage: "",
+ messageAgePurgeMode: "days",
+ messageAgePurgeDays: 90,
+ messageAgePurgeBeforeDate: "",
+ messageAgePurgePreviewCount: null,
+ messageAgePurgePreviewLoading: false,
+ messageAgePurgeBusy: false,
searchQuery: "",
activeSettingsTab: DEFAULT_SETTINGS_TAB,
micronWasmUpdateModalOpen: false,
@@ -5567,6 +5702,80 @@ export default {
ToastUtils.error(this.$t("common.error"));
}
},
+ async clearDuplicateMessages() {
+ if (!(await DialogUtils.confirm(this.$t("maintenance.clear_duplicates_confirm")))) return;
+ try {
+ const { deleted } = await maintenanceClient.clearDuplicateMessages(window.api);
+ ToastUtils.success(this.$t("maintenance.clear_duplicates_done", { count: deleted }));
+ } catch {
+ ToastUtils.error(this.$t("common.error"));
+ }
+ },
+ messageAgeFilterParams() {
+ return maintenanceClient.buildMessageAgeFilterParams({
+ mode: this.messageAgePurgeMode,
+ days: this.messageAgePurgeDays,
+ beforeDate: this.messageAgePurgeBeforeDate,
+ });
+ },
+ async refreshMessageAgePurgePreview() {
+ const params = this.messageAgeFilterParams();
+ if (!params) {
+ this.messageAgePurgePreviewCount = null;
+ ToastUtils.warning(this.$t("maintenance.purge_filter_invalid"));
+ return;
+ }
+ this.messageAgePurgePreviewLoading = true;
+ try {
+ const { count } = await maintenanceClient.previewMessageAgePurge(window.api, params);
+ this.messageAgePurgePreviewCount = count;
+ } catch {
+ this.messageAgePurgePreviewCount = null;
+ ToastUtils.error(this.$t("common.error"));
+ } finally {
+ this.messageAgePurgePreviewLoading = false;
+ }
+ },
+ async exportOldMessagesArchive() {
+ const params = this.messageAgeFilterParams();
+ if (!params) {
+ ToastUtils.warning(this.$t("maintenance.purge_filter_invalid"));
+ return;
+ }
+ this.messageAgePurgeBusy = true;
+ try {
+ const bundle = await maintenanceClient.exportMessagesBundle(window.api, params);
+ const dataStr = JSON.stringify(bundle, null, 2);
+ const blob = new Blob([dataStr], { type: "application/json" });
+ const stamp =
+ params.before || (params.older_than_days != null ? `${params.older_than_days}d` : "filtered");
+ const exportFileDefaultName = `meshchat_messages_archive_${stamp}_${new Date().toISOString().slice(0, 10)}.json`;
+ await DownloadUtils.downloadFile(exportFileDefaultName, blob);
+ ToastUtils.success(this.$t("maintenance.export_old_archive_done"));
+ } catch {
+ ToastUtils.error(this.$t("common.error"));
+ } finally {
+ this.messageAgePurgeBusy = false;
+ }
+ },
+ async purgeOldMessages() {
+ const params = this.messageAgeFilterParams();
+ if (!params) {
+ ToastUtils.warning(this.$t("maintenance.purge_filter_invalid"));
+ return;
+ }
+ if (!(await DialogUtils.confirm(this.$t("maintenance.purge_old_confirm")))) return;
+ this.messageAgePurgeBusy = true;
+ try {
+ const { deleted } = await maintenanceClient.purgeMessagesByAge(window.api, params);
+ this.messageAgePurgePreviewCount = 0;
+ ToastUtils.success(this.$t("maintenance.purge_old_done", { count: deleted }));
+ } catch {
+ ToastUtils.error(this.$t("common.error"));
+ } finally {
+ this.messageAgePurgeBusy = false;
+ }
+ },
async clearAnnounces() {
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
try {
@@ -5737,11 +5946,12 @@ export default {
},
async exportMessages() {
try {
- const response = await window.api.get("/api/v1/maintenance/messages/export");
- const dataStr = JSON.stringify(response.data, null, 2);
+ const bundle = await maintenanceClient.exportMessagesBundle(window.api);
+ const dataStr = JSON.stringify(bundle, null, 2);
const blob = new Blob([dataStr], { type: "application/json" });
const exportFileDefaultName = `meshchat_messages_${new Date().toISOString().slice(0, 10)}.json`;
await DownloadUtils.downloadFile(exportFileDefaultName, blob);
+ ToastUtils.success(this.$t("maintenance.export_messages_done"));
} catch {
ToastUtils.error(this.$t("common.error"));
}
diff --git a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
index 54be8762..8e4842e9 100644
--- a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
+++ b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
@@ -73,6 +73,8 @@ export const CORE_SETTINGS_SECTION_KEYWORDS = {
"maintenance.description",
"maintenance.clear_messages",
"maintenance.clear_messages_desc",
+ "maintenance.clear_duplicates",
+ "maintenance.clear_duplicates_desc",
"maintenance.clear_announces",
"maintenance.clear_announces_desc",
"maintenance.clear_nomadnet_favs",
@@ -93,6 +95,9 @@ export const CORE_SETTINGS_SECTION_KEYWORDS = {
"maintenance.export_messages_desc",
"maintenance.import_messages",
"maintenance.import_messages_desc",
+ "maintenance.purge_old_title",
+ "maintenance.purge_old_desc",
+ "maintenance.export_old_archive",
"maintenance.export_nomadnet_favourites",
"maintenance.import_nomadnet_favourites",
"Automatic Backup Limit",
diff --git a/meshchatx/src/frontend/js/settings/settingsMaintenanceClient.js b/meshchatx/src/frontend/js/settings/settingsMaintenanceClient.js
index d84badf5..4c404bad 100644
--- a/meshchatx/src/frontend/js/settings/settingsMaintenanceClient.js
+++ b/meshchatx/src/frontend/js/settings/settingsMaintenanceClient.js
@@ -9,6 +9,73 @@ export async function clearMessages(api) {
await api.delete("/api/v1/maintenance/messages");
}
+/**
+ * @param {{ get: (path: string) => Promise<{ data?: { count?: number } }> }} api
+ */
+export async function previewDuplicateMessages(api) {
+ const response = await api.get("/api/v1/maintenance/messages/duplicates");
+ return { count: Number(response?.data?.count) || 0 };
+}
+
+/**
+ * @param {{ delete: (path: string) => Promise<{ data?: { deleted?: number } }> }} api
+ */
+export async function clearDuplicateMessages(api) {
+ const response = await api.delete("/api/v1/maintenance/messages/duplicates");
+ return { deleted: Number(response?.data?.deleted) || 0 };
+}
+
+/**
+ * Build query params for age-based message purge/export.
+ * @param {{ mode: "days"|"date", days?: number, beforeDate?: string }} opts
+ * @returns {{ older_than_days?: number, before?: string }|null}
+ */
+export function buildMessageAgeFilterParams(opts) {
+ if (!opts || typeof opts !== "object") return null;
+ if (opts.mode === "date") {
+ const before = typeof opts.beforeDate === "string" ? opts.beforeDate.trim() : "";
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(before)) return null;
+ return { before };
+ }
+ const days = Number(opts.days);
+ if (!Number.isFinite(days) || days < 1 || days > 10000) return null;
+ return { older_than_days: Math.floor(days) };
+}
+
+/**
+ * @param {{ get: (path: string, config?: object) => Promise<{ data?: { count?: number, cutoff?: number } }> }} api
+ * @param {{ older_than_days?: number, before?: string }} params
+ */
+export async function previewMessageAgePurge(api, params) {
+ const response = await api.get("/api/v1/maintenance/messages/purge-preview", { params });
+ return {
+ count: Number(response?.data?.count) || 0,
+ cutoff: response?.data?.cutoff,
+ };
+}
+
+/**
+ * @param {{ delete: (path: string, config?: object) => Promise<{ data?: { deleted?: number } }> }} api
+ * @param {{ older_than_days?: number, before?: string }} params
+ */
+export async function purgeMessagesByAge(api, params) {
+ const response = await api.delete("/api/v1/maintenance/messages", { params });
+ return {
+ deleted: Number(response?.data?.deleted) || 0,
+ cutoff: response?.data?.cutoff,
+ };
+}
+
+/**
+ * @param {{ get: (path: string, config?: object) => Promise<{ data?: object }> }} api
+ * @param {{ older_than_days?: number, before?: string }|null|undefined} [params]
+ */
+export async function exportMessagesBundle(api, params) {
+ const config = params && Object.keys(params).length ? { params } : undefined;
+ const response = await api.get("/api/v1/maintenance/messages/export", config);
+ return response?.data;
+}
+
/**
* @param {{ delete: (path: string) => Promise<unknown> }} api
*/
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index c5e662f3..5abe2c6b 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -919,9 +919,9 @@
"clear_path_table_desc": "Entfernt alle zwischengespeicherten Reticulum-Transportpfade. Routen werden bei Bedarf neu entdeckt.",
"path_table_cleared": "Pfadtabelle geleert",
"export_messages": "Nachrichten exportieren",
- "export_messages_desc": "Konversationen als JSON herunterladen, inklusive Kontakte, Anzeigenamen und Lesestatus.",
+ "export_messages_desc": "Download a reimportable JSON archive (messages, attachments, contacts, read state).",
"import_messages": "Nachrichten importieren",
- "import_messages_desc": "Konversationen aus einer JSON-Datei wiederherstellen.",
+ "import_messages_desc": "Restore conversations from a previously exported JSON archive.",
"clear_confirm": "Sind Sie sicher? Diese Aktion kann nicht rückgängig gemacht werden.",
"messages_cleared": "Nachrichten erfolgreich gelöscht",
"announces_cleared": "Ankündigungen erfolgreich gelöscht",
@@ -936,7 +936,28 @@
"nomadnet_favourites_exported": "NomadNet-Favoriten-Layout exportiert",
"nomadnet_favourites_export_failed": "NomadNet-Favoriten konnten nicht exportiert werden",
"nomadnet_favourites_imported": "NomadNet-Favoriten-Layout importiert",
- "nomadnet_favourites_import_failed": "NomadNet-Favoriten-Datei konnte nicht importiert werden"
+ "nomadnet_favourites_import_failed": "NomadNet-Favoriten-Datei konnte nicht importiert werden",
+ "purge_old_title": "Clean up old messages",
+ "purge_old_desc": "Delete local messages older than a number of days or before a date. Attachments stored in those messages are removed too. Export an archive first if you may want to reimport later.",
+ "purge_mode_days": "Older than days",
+ "purge_mode_date": "Before date",
+ "purge_older_than_days": "Delete messages older than (days)",
+ "purge_before_date": "Delete messages before",
+ "purge_preview": "Count matching",
+ "purge_preview_loading": "Counting matching messages...",
+ "purge_preview_hint": "Choose a filter, then count matching messages.",
+ "purge_preview_count": "{count} messages match this filter",
+ "purge_filter_invalid": "Enter a valid day count or date.",
+ "export_old_archive": "Export matching archive",
+ "export_old_archive_done": "Matching message archive downloaded",
+ "purge_old_confirm_btn": "Delete matching",
+ "purge_old_confirm": "Permanently delete matching local messages and their attachments? This cannot be undone.",
+ "purge_old_done": "Deleted {count} messages",
+ "export_messages_done": "Message archive downloaded",
+ "clear_duplicates": "Clear Duplicate Messages",
+ "clear_duplicates_desc": "Remove extra copies that share the same text in the same conversation. Keeps the oldest of each set.",
+ "clear_duplicates_confirm": "Delete duplicate messages that match by content in each conversation? The oldest copy of each set is kept. This cannot be undone.",
+ "clear_duplicates_done": "Removed {count} duplicate messages"
},
"identities": {
"title": "Identitäten",
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 17ad92b8..e60f2eac 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -897,8 +897,28 @@
"maintenance": {
"title": "Maintenance & Data",
"description": "Manage your local data, clear caches, and backup conversations.",
+ "purge_old_title": "Clean up old messages",
+ "purge_old_desc": "Delete local messages older than a number of days or before a date. Attachments stored in those messages are removed too. Export an archive first if you may want to reimport later.",
+ "purge_mode_days": "Older than days",
+ "purge_mode_date": "Before date",
+ "purge_older_than_days": "Delete messages older than (days)",
+ "purge_before_date": "Delete messages before",
+ "purge_preview": "Count matching",
+ "purge_preview_loading": "Counting matching messages...",
+ "purge_preview_hint": "Choose a filter, then count matching messages.",
+ "purge_preview_count": "{count} messages match this filter",
+ "purge_filter_invalid": "Enter a valid day count or date.",
+ "export_old_archive": "Export matching archive",
+ "export_old_archive_done": "Matching message archive downloaded",
+ "purge_old_confirm_btn": "Delete matching",
+ "purge_old_confirm": "Permanently delete matching local messages and their attachments? This cannot be undone.",
+ "purge_old_done": "Deleted {count} messages",
"clear_messages": "Clear All Messages",
"clear_messages_desc": "Permanently delete all sent and received messages.",
+ "clear_duplicates": "Clear Duplicate Messages",
+ "clear_duplicates_desc": "Remove extra copies that share the same text in the same conversation. Keeps the oldest of each set.",
+ "clear_duplicates_confirm": "Delete duplicate messages that match by content in each conversation? The oldest copy of each set is kept. This cannot be undone.",
+ "clear_duplicates_done": "Removed {count} duplicate messages",
"clear_announces": "Clear All Announces",
"clear_announces_desc": "Remove all discovered peers and nodes from your cache.",
"clear_nomadnet_favs": "Clear NomadNetwork Favorites",
@@ -919,9 +939,10 @@
"clear_path_table_desc": "Drop all cached Reticulum transport paths. Routes will be re-discovered as needed.",
"path_table_cleared": "Path table cleared",
"export_messages": "Export Messages",
- "export_messages_desc": "Download conversations as JSON, including contacts, display names, and read state.",
+ "export_messages_desc": "Download a reimportable JSON archive (messages, attachments, contacts, read state).",
+ "export_messages_done": "Message archive downloaded",
"import_messages": "Import Messages",
- "import_messages_desc": "Restore conversations from a JSON file.",
+ "import_messages_desc": "Restore conversations from a previously exported JSON archive.",
"clear_confirm": "Are you sure? This action cannot be undone.",
"messages_cleared": "Messages cleared successfully",
"announces_cleared": "Announces cleared successfully",
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index 6a1d6bb7..625aab09 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -919,9 +919,9 @@
"clear_path_table_desc": "Elimina todas las rutas de transporte Reticulum en caché. Las rutas se redescubrirán según sea necesario.",
"path_table_cleared": "Tabla de rutas borrada",
"export_messages": "Mensajes de exportación",
- "export_messages_desc": "Descarga las conversaciones como JSON, incluyendo contactos, nombres y estado de lectura.",
+ "export_messages_desc": "Download a reimportable JSON archive (messages, attachments, contacts, read state).",
"import_messages": "Importar mensajes",
- "import_messages_desc": "Restaurar conversaciones de un archivo JSON.",
+ "import_messages_desc": "Restore conversations from a previously exported JSON archive.",
"clear_confirm": "¿Estás seguro? Esta acción no se puede deshacer.",
"messages_cleared": "Mensajes resueltos con éxito",
"announces_cleared": "Anuncios aclarados con éxito",
@@ -936,7 +936,28 @@
"nomadnet_favourites_exported": "Diseño de favoritos NomadNet exportado",
"nomadnet_favourites_export_failed": "No se pudieron exportar los favoritos NomadNet",
"nomadnet_favourites_imported": "Diseño de favoritos NomadNet importado",
- "nomadnet_favourites_import_failed": "No se pudo importar el archivo de favoritos NomadNet"
+ "nomadnet_favourites_import_failed": "No se pudo importar el archivo de favoritos NomadNet",
+ "purge_old_title": "Clean up old messages",
+ "purge_old_desc": "Delete local messages older than a number of days or before a date. Attachments stored in those messages are removed too. Export an archive first if you may want to reimport later.",
+ "purge_mode_days": "Older than days",
+ "purge_mode_date": "Before date",
+ "purge_older_than_days": "Delete messages older than (days)",
+ "purge_before_date": "Delete messages before",
+ "purge_preview": "Count matching",
+ "purge_preview_loading": "Counting matching messages...",
+ "purge_preview_hint": "Choose a filter, then count matching messages.",
+ "purge_preview_count": "{count} messages match this filter",
+ "purge_filter_invalid": "Enter a valid day count or date.",
+ "export_old_archive": "Export matching archive",
+ "export_old_archive_done": "Matching message archive downloaded",
+ "purge_old_confirm_btn": "Delete matching",
+ "purge_old_confirm": "Permanently delete matching local messages and their attachments? This cannot be undone.",
+ "purge_old_done": "Deleted {count} messages",
+ "export_messages_done": "Message archive downloaded",
+ "clear_duplicates": "Clear Duplicate Messages",
+ "clear_duplicates_desc": "Remove extra copies that share the same text in the same conversation. Keeps the oldest of each set.",
+ "clear_duplicates_confirm": "Delete duplicate messages that match by content in each conversation? The oldest copy of each set is kept. This cannot be undone.",
+ "clear_duplicates_done": "Removed {count} duplicate messages"
},
"identities": {
"title": "Identidades",
diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 9111e01b..ba0f3856 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -919,9 +919,9 @@
"clear_path_table_desc": "Unohda välimuistissa olevat Reticulum-välityksen polut. Reitit löydetään uudelleen tarpeen mukaan.",
"path_table_cleared": "Polkutaulukko tyhjätty",
"export_messages": "Vie viestejä",
- "export_messages_desc": "Lataa keskustelut JSON-tiedostona, mukaan lukien yhteystiedot, nimet ja lukutila.",
+ "export_messages_desc": "Download a reimportable JSON archive (messages, attachments, contacts, read state).",
"import_messages": "Tuo viestejä",
- "import_messages_desc": "Palauta keskustelut JSON-tiedostosta.",
+ "import_messages_desc": "Restore conversations from a previously exported JSON archive.",
"clear_confirm": "Oletko varma? Toimintoa ei voi peruuttaa.",
"messages_cleared": "Viestit poistettu",
"announces_cleared": "Kuulutukset poistettu",
@@ -936,7 +936,28 @@
"nomadnet_favourites_exported": "NomadNet-suosikit viety",
"nomadnet_favourites_export_failed": "NomadNet-suosikkien vienti epäonnistui",
"nomadnet_favourites_imported": "NomadNet-suosikit tuotu",
- "nomadnet_favourites_import_failed": "NomadNet-suosikkien tuonti tiedostosta epäonnistui"
+ "nomadnet_favourites_import_failed": "NomadNet-suosikkien tuonti tiedostosta epäonnistui",
+ "purge_old_title": "Clean up old messages",
+ "purge_old_desc": "Delete local messages older than a number of days or before a date. Attachments stored in those messages are removed too. Export an archive first if you may want to reimport later.",
+ "purge_mode_days": "Older than days",
+ "purge_mode_date": "Before date",
+ "purge_older_than_days": "Delete messages older than (days)",
+ "purge_before_date": "Delete messages before",
+ "purge_preview": "Count matching",
+ "purge_preview_loading": "Counting matching messages...",
+ "purge_preview_hint": "Choose a filter, then count matching messages.",
+ "purge_preview_count": "{count} messages match this filter",
+ "purge_filter_invalid": "Enter a valid day count or date.",
+ "export_old_archive": "Export matching archive",
+ "export_old_archive_done": "Matching message archive downloaded",
+ "purge_old_confirm_btn": "Delete matching",
+ "purge_old_confirm": "Permanently delete matching local messages and their attachments? This cannot be undone.",
+ "purge_old_done": "Deleted {count} messages",
+ "export_messages_done": "Message archive downloaded",
+ "clear_duplicates": "Clear Duplicate Messages",
+ "clear_duplicates_desc": "Remove extra copies that share the same text in the same conversation. Keeps the oldest of each set.",
+ "clear_duplicates_confirm": "Delete duplicate messages that match by content in each conversation? The oldest copy of each set is kept. This cannot be undone.",
+ "clear_duplicates_done": "Removed {count} duplicate messages"
},
"identities": {
"title": "Identiteetit",
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 5b55dc24..3fcd76c0 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -919,9 +919,9 @@
"clear_path_table_desc": "Supprime tous les chemins de transport Reticulum en cache. Les routes seront redécouvertes au besoin.",
"path_table_cleared": "Table de chemins effacée",
"export_messages": "Exporter des messages",
- "export_messages_desc": "Télécharger les conversations en JSON, avec contacts, noms affichés et état de lecture.",
+ "export_messages_desc": "Download a reimportable JSON archive (messages, attachments, contacts, read state).",
"import_messages": "Importer des messages",
- "import_messages_desc": "Restaurer les conversations à partir d'un fichier JSON.",
+ "import_messages_desc": "Restore conversations from a previously exported JSON archive.",
"clear_confirm": "Tu es sûr ? Cette action ne peut être annulée.",
"messages_cleared": "Messages effacés avec succès",
"announces_cleared": "Annonces approuvées avec succès",
@@ -936,7 +936,28 @@
"nomadnet_favourites_exported": "Disposition des favoris NomadNet exportée",
"nomadnet_favourites_export_failed": "Impossible d'exporter les favoris NomadNet",
"nomadnet_favourites_imported": "Disposition des favoris NomadNet importée",
- "nomadnet_favourites_import_failed": "Impossible d'importer le fichier des favoris NomadNet"
+ "nomadnet_favourites_import_failed": "Impossible d'importer le fichier des favoris NomadNet",
+ "purge_old_title": "Clean up old messages",
+ "purge_old_desc": "Delete local messages older than a number of days or before a date. Attachments stored in those messages are removed too. Export an archive first if you may want to reimport later.",
+ "purge_mode_days": "Older than days",
+ "purge_mode_date": "Before date",
+ "purge_older_than_days": "Delete messages older than (days)",
+ "purge_before_date": "Delete messages before",
+ "purge_preview": "Count matching",
+ "purge_preview_loading": "Counting matching messages...",
+ "purge_preview_hint": "Choose a filter, then count matching messages.",
+ "purge_preview_count": "{count} messages match this filter",
+ "purge_filter_invalid": "Enter a valid day count or date.",
+ "export_old_archive": "Export matching archive",
+ "export_old_archive_done": "Matching message archive downloaded",
+ "purge_old_confirm_btn": "Delete matching",
+ "purge_old_confirm": "Permanently delete matching local messages and their attachments? This cannot be undone.",
+ "purge_old_done": "Deleted {count} messages",
+ "export_messages_done": "Message archive downloaded",
+ "clear_duplicates": "Clear Duplicate Messages",
+ "clear_duplicates_desc": "Remove extra copies that share the same text in the same conversation. Keeps the oldest of each set.",
+ "clear_duplicates_confirm": "Delete duplicate messages that match by content in each conversation? The oldest copy of each set is kept. This cannot be undone.",
+ "clear_duplicates_done": "Removed {count} duplicate messages"
},
"identities": {
"title": "Identités",
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index f987faa8..fd0ee691 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -919,9 +919,9 @@
"clear_path_table_desc": "Elimina tutti i percorsi di trasporto Reticulum memorizzati. I percorsi verranno riscoperti quando necessario.",
"path_table_cleared": "Tabella percorsi svuotata",
"export_messages": "Esporta Messaggi",
- "export_messages_desc": "Scarica le conversazioni come JSON, inclusi contatti, nomi visualizzati e stato di lettura.",
+ "export_messages_desc": "Download a reimportable JSON archive (messages, attachments, contacts, read state).",
"import_messages": "Importa Messaggi",
- "import_messages_desc": "Ripristina le conversazioni da un file JSON.",
+ "import_messages_desc": "Restore conversations from a previously exported JSON archive.",
"clear_confirm": "Sei sicuro? Questa azione non può essere annullata.",
"messages_cleared": "Messaggi cancellati con successo",
"announces_cleared": "Annunci cancellati con successo",
@@ -936,7 +936,28 @@
"nomadnet_favourites_exported": "Layout preferiti NomadNet esportato",
"nomadnet_favourites_export_failed": "Impossibile esportare i preferiti NomadNet",
"nomadnet_favourites_imported": "Layout preferiti NomadNet importato",
- "nomadnet_favourites_import_failed": "Impossibile importare il file dei preferiti NomadNet"
+ "nomadnet_favourites_import_failed": "Impossibile importare il file dei preferiti NomadNet",
+ "purge_old_title": "Clean up old messages",
+ "purge_old_desc": "Delete local messages older than a number of days or before a date. Attachments stored in those messages are removed too. Export an archive first if you may want to reimport later.",
+ "purge_mode_days": "Older than days",
+ "purge_mode_date": "Before date",
+ "purge_older_than_days": "Delete messages older than (days)",
+ "purge_before_date": "Delete messages before",
+ "purge_preview": "Count matching",
+ "purge_preview_loading": "Counting matching messages...",
+ "purge_preview_hint": "Choose a filter, then count matching messages.",
+ "purge_preview_count": "{count} messages match this filter",
+ "purge_filter_invalid": "Enter a valid day count or date.",
+ "export_old_archive": "Export matching archive",
+ "export_old_archive_done": "Matching message archive downloaded",
+ "purge_old_confirm_btn": "Delete matching",
+ "purge_old_confirm": "Permanently delete matching local messages and their attachments? This cannot be undone.",
+ "purge_old_done": "Deleted {count} messages",
+ "export_messages_done": "Message archive downloaded",
+ "clear_duplicates": "Clear Duplicate Messages",
+ "clear_duplicates_desc": "Remove extra copies that share the same text in the same conversation. Keeps the oldest of each set.",
+ "clear_duplicates_confirm": "Delete duplicate messages that match by content in each conversation? The oldest copy of each set is kept. This cannot be undone.",
+ "clear_duplicates_done": "Removed {count} duplicate messages"
},
"identities": {
"title": "Identità",
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index dda72bed..dfa57852 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -919,9 +919,9 @@
"clear_path_table_desc": "Verwijdert alle gecachte Reticulum-transportpaden. Routes worden opnieuw ontdekt wanneer nodig.",
"path_table_cleared": "Padtabel gewist",
"export_messages": "Berichten exporteren",
- "export_messages_desc": "Download gesprekken als JSON, inclusief contacten, weergavenamen en leesstatus.",
+ "export_messages_desc": "Download a reimportable JSON archive (messages, attachments, contacts, read state).",
"import_messages": "Berichten importeren",
- "import_messages_desc": "Gesprekken uit een JSON-bestand herstellen.",
+ "import_messages_desc": "Restore conversations from a previously exported JSON archive.",
"clear_confirm": "Weet je het zeker? Deze actie kan niet ongedaan worden gemaakt.",
"messages_cleared": "Berichten succesvol gewist",
"announces_cleared": "Kondigen succesvol aan",
@@ -936,7 +936,28 @@
"nomadnet_favourites_exported": "NomadNet-favorietenindeling geëxporteerd",
"nomadnet_favourites_export_failed": "NomadNet-favorieten konden niet worden geëxporteerd",
"nomadnet_favourites_imported": "NomadNet-favorietenindeling geïmporteerd",
- "nomadnet_favourites_import_failed": "NomadNet-favorietenbestand kon niet worden geïmporteerd"
+ "nomadnet_favourites_import_failed": "NomadNet-favorietenbestand kon niet worden geïmporteerd",
+ "purge_old_title": "Clean up old messages",
+ "purge_old_desc": "Delete local messages older than a number of days or before a date. Attachments stored in those messages are removed too. Export an archive first if you may want to reimport later.",
+ "purge_mode_days": "Older than days",
+ "purge_mode_date": "Before date",
+ "purge_older_than_days": "Delete messages older than (days)",
+ "purge_before_date": "Delete messages before",
+ "purge_preview": "Count matching",
+ "purge_preview_loading": "Counting matching messages...",
+ "purge_preview_hint": "Choose a filter, then count matching messages.",
+ "purge_preview_count": "{count} messages match this filter",
+ "purge_filter_invalid": "Enter a valid day count or date.",
+ "export_old_archive": "Export matching archive",
+ "export_old_archive_done": "Matching message archive downloaded",
+ "purge_old_confirm_btn": "Delete matching",
+ "purge_old_confirm": "Permanently delete matching local messages and their attachments? This cannot be undone.",
+ "purge_old_done": "Deleted {count} messages",
+ "export_messages_done": "Message archive downloaded",
+ "clear_duplicates": "Clear Duplicate Messages",
+ "clear_duplicates_desc": "Remove extra copies that share the same text in the same conversation. Keeps the oldest of each set.",
+ "clear_duplicates_confirm": "Delete duplicate messages that match by content in each conversation? The oldest copy of each set is kept. This cannot be undone.",
+ "clear_duplicates_done": "Removed {count} duplicate messages"
},
"identities": {
"title": "Identiteiten",
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index c84b2141..5ff34525 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -919,9 +919,9 @@
"clear_path_table_desc": "Удаляет все кэшированные транспортные пути Reticulum. Маршруты будут обнаружены заново по мере необходимости.",
"path_table_cleared": "Таблица путей очищена",
"export_messages": "Экспортировать сообщения",
- "export_messages_desc": "Скачать переписки в JSON вместе с контактами, именами и статусом прочтения.",
+ "export_messages_desc": "Download a reimportable JSON archive (messages, attachments, contacts, read state).",
"import_messages": "Импортировать сообщения",
- "import_messages_desc": "Восстановить разговоры из JSON-файла.",
+ "import_messages_desc": "Restore conversations from a previously exported JSON archive.",
"clear_confirm": "Вы уверены? Это действие нельзя отменить.",
"messages_cleared": "Сообщения успешно очищены",
"announces_cleared": "Объявления успешно очищены",
@@ -936,7 +936,28 @@
"nomadnet_favourites_exported": "Макет избранного NomadNet экспортирован",
"nomadnet_favourites_export_failed": "Не удалось экспортировать избранное NomadNet",
"nomadnet_favourites_imported": "Макет избранного NomadNet импортирован",
- "nomadnet_favourites_import_failed": "Не удалось импортировать файл избранного NomadNet"
+ "nomadnet_favourites_import_failed": "Не удалось импортировать файл избранного NomadNet",
+ "purge_old_title": "Clean up old messages",
+ "purge_old_desc": "Delete local messages older than a number of days or before a date. Attachments stored in those messages are removed too. Export an archive first if you may want to reimport later.",
+ "purge_mode_days": "Older than days",
+ "purge_mode_date": "Before date",
+ "purge_older_than_days": "Delete messages older than (days)",
+ "purge_before_date": "Delete messages before",
+ "purge_preview": "Count matching",
+ "purge_preview_loading": "Counting matching messages...",
+ "purge_preview_hint": "Choose a filter, then count matching messages.",
+ "purge_preview_count": "{count} messages match this filter",
+ "purge_filter_invalid": "Enter a valid day count or date.",
+ "export_old_archive": "Export matching archive",
+ "export_old_archive_done": "Matching message archive downloaded",
+ "purge_old_confirm_btn": "Delete matching",
+ "purge_old_confirm": "Permanently delete matching local messages and their attachments? This cannot be undone.",
+ "purge_old_done": "Deleted {count} messages",
+ "export_messages_done": "Message archive downloaded",
+ "clear_duplicates": "Clear Duplicate Messages",
+ "clear_duplicates_desc": "Remove extra copies that share the same text in the same conversation. Keeps the oldest of each set.",
+ "clear_duplicates_confirm": "Delete duplicate messages that match by content in each conversation? The oldest copy of each set is kept. This cannot be undone.",
+ "clear_duplicates_done": "Removed {count} duplicate messages"
},
"identities": {
"title": "Личности",
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index fb580ef9..a28d162a 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -919,9 +919,9 @@
"clear_path_table_desc": "删除所有缓存的 Reticulum 传输路径。需要时将重新发现路由。",
"path_table_cleared": "路径表已清除",
"export_messages": "导出消息",
- "export_messages_desc": "将对话下载为 JSON,包含联系人、显示名称和已读状态。",
+ "export_messages_desc": "Download a reimportable JSON archive (messages, attachments, contacts, read state).",
"import_messages": "导入消息",
- "import_messages_desc": "从 JSON 文件恢复对话。",
+ "import_messages_desc": "Restore conversations from a previously exported JSON archive.",
"clear_confirm": "您确定吗?此操作无法撤销。",
"messages_cleared": "消息清除成功",
"announces_cleared": "广播清除成功",
@@ -936,7 +936,28 @@
"nomadnet_favourites_exported": "NomadNet 收藏布局已导出",
"nomadnet_favourites_export_failed": "无法导出 NomadNet 收藏",
"nomadnet_favourites_imported": "NomadNet 收藏布局已导入",
- "nomadnet_favourites_import_failed": "无法导入 NomadNet 收藏文件"
+ "nomadnet_favourites_import_failed": "无法导入 NomadNet 收藏文件",
+ "purge_old_title": "Clean up old messages",
+ "purge_old_desc": "Delete local messages older than a number of days or before a date. Attachments stored in those messages are removed too. Export an archive first if you may want to reimport later.",
+ "purge_mode_days": "Older than days",
+ "purge_mode_date": "Before date",
+ "purge_older_than_days": "Delete messages older than (days)",
+ "purge_before_date": "Delete messages before",
+ "purge_preview": "Count matching",
+ "purge_preview_loading": "Counting matching messages...",
+ "purge_preview_hint": "Choose a filter, then count matching messages.",
+ "purge_preview_count": "{count} messages match this filter",
+ "purge_filter_invalid": "Enter a valid day count or date.",
+ "export_old_archive": "Export matching archive",
+ "export_old_archive_done": "Matching message archive downloaded",
+ "purge_old_confirm_btn": "Delete matching",
+ "purge_old_confirm": "Permanently delete matching local messages and their attachments? This cannot be undone.",
+ "purge_old_done": "Deleted {count} messages",
+ "export_messages_done": "Message archive downloaded",
+ "clear_duplicates": "Clear Duplicate Messages",
+ "clear_duplicates_desc": "Remove extra copies that share the same text in the same conversation. Keeps the oldest of each set.",
+ "clear_duplicates_confirm": "Delete duplicate messages that match by content in each conversation? The oldest copy of each set is kept. This cannot be undone.",
+ "clear_duplicates_done": "Removed {count} duplicate messages"
},
"identities": {
"title": "身份",
diff --git a/tests/backend/fixtures/http_api_routes.json b/tests/backend/fixtures/http_api_routes.json
index be4cd6cb..9c9ea6ba 100644
--- a/tests/backend/fixtures/http_api_routes.json
+++ b/tests/backend/fixtures/http_api_routes.json
@@ -540,6 +540,18 @@
"method": "GET",
"path": "/api/v1/maintenance/messages/export"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/maintenance/messages/duplicates"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/messages/duplicates"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/maintenance/messages/purge-preview"
+ },
{
"method": "POST",
"path": "/api/v1/maintenance/messages/import"
diff --git a/tests/backend/http_api_response_registry.py b/tests/backend/http_api_response_registry.py
index c0945288..84d2dc36 100644
--- a/tests/backend/http_api_response_registry.py
+++ b/tests/backend/http_api_response_registry.py
@@ -61,6 +61,8 @@ from tests.backend.http_api_response_schemas import (
LXMF_PROPAGATION_NODES_SCHEMA,
LXMF_PROPAGATION_STATUS_SCHEMA,
LXMF_SIEVE_FILTERS_SCHEMA,
+ MAINTENANCE_MESSAGES_PURGE_PREVIEW_SCHEMA,
+ MAINTENANCE_MESSAGES_DUPLICATES_SCHEMA,
MAP_DRAWINGS_SCHEMA,
MAP_MBTILES_SCHEMA,
MAP_OFFLINE_SCHEMA,
@@ -133,6 +135,17 @@ HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = (
HttpJsonContract("GET", "/api/v1/auth/csrf", CSRF_ENVELOPE_SCHEMA),
HttpJsonContract("GET", "/api/v1/server/security", SERVER_SECURITY_SCHEMA),
HttpJsonContract("GET", "/api/v1/config", CONFIG_ENVELOPE_SCHEMA),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/maintenance/messages/purge-preview",
+ MAINTENANCE_MESSAGES_PURGE_PREVIEW_SCHEMA,
+ query={"older_than_days": "30"},
+ ),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/maintenance/messages/duplicates",
+ MAINTENANCE_MESSAGES_DUPLICATES_SCHEMA,
+ ),
HttpJsonContract(
"GET",
"/api/v1/blocked-destinations",
diff --git a/tests/backend/http_api_response_schemas.py b/tests/backend/http_api_response_schemas.py
index 2a3ce679..598156c8 100644
--- a/tests/backend/http_api_response_schemas.py
+++ b/tests/backend/http_api_response_schemas.py
@@ -25,6 +25,25 @@ MESSAGE_ENVELOPE_SCHEMA: dict = {
"additionalProperties": True,
}
+MAINTENANCE_MESSAGES_PURGE_PREVIEW_SCHEMA: dict = {
+ "type": "object",
+ "required": ["count", "cutoff"],
+ "properties": {
+ "count": {"type": "integer", "minimum": 0},
+ "cutoff": _NUMBER,
+ },
+ "additionalProperties": False,
+}
+
+MAINTENANCE_MESSAGES_DUPLICATES_SCHEMA: dict = {
+ "type": "object",
+ "required": ["count"],
+ "properties": {
+ "count": {"type": "integer", "minimum": 0},
+ },
+ "additionalProperties": False,
+}
+
CONFIG_ENVELOPE_SCHEMA: dict = {
"type": "object",
"required": ["config"],
diff --git a/tests/backend/test_auto_resend_guard.py b/tests/backend/test_auto_resend_guard.py
new file mode 100644
index 00000000..11099ee2
--- /dev/null
+++ b/tests/backend/test_auto_resend_guard.py
@@ -0,0 +1,199 @@
+# SPDX-License-Identifier: 0BSD
+
+import asyncio
+import time
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from meshchatx.src.backend import auto_resend_guard as guard
+from meshchatx.src.backend.database import Database
+from meshchatx.src.backend.database.provider import DatabaseProvider
+from meshchatx.src.backend.database.schema import DatabaseSchema
+
+
+def test_auto_resend_count_helpers():
+ assert guard.read_auto_resend_count(None) == 0
+ assert guard.read_auto_resend_count("{}") == 0
+ assert guard.read_auto_resend_count('{"_mcx_auto_resend_count": 2}') == 2
+ assert guard.should_skip_for_budget('{"_mcx_auto_resend_count": 3}')
+ assert not guard.should_skip_for_budget('{"_mcx_auto_resend_count": 2}')
+ assert guard.next_attempt_count("{}") == 1
+ out = guard.fields_with_auto_resend_count('{"image":{"x":1}}', 2)
+ assert '"_mcx_auto_resend_count": 2' in out
+ assert '"image"' in out
+
+
+@pytest.fixture
+def _db_path(tmp_path):
+ return str(tmp_path / "auto_resend.db")
+
+
+def _insert_failed(db, *, msg_hash, peer, content, fields="{}", ts=None):
+ now = ts if ts is not None else time.time()
+ db.messages.upsert_lxmf_message(
+ {
+ "hash": msg_hash,
+ "source_hash": peer,
+ "destination_hash": peer,
+ "peer_hash": peer,
+ "state": "failed",
+ "progress": 0,
+ "is_incoming": 0,
+ "method": "opportunistic",
+ "delivery_attempts": 0,
+ "next_delivery_attempt_at": None,
+ "title": "",
+ "content": content,
+ "fields": fields,
+ "rssi": None,
+ "snr": None,
+ "quality": None,
+ "is_spam": 0,
+ "reply_to_hash": None,
+ "attachments_stripped": 0,
+ "timestamp": now,
+ },
+ )
+
+
+def test_claim_failed_message_is_atomic(_db_path):
+ provider = DatabaseProvider(_db_path)
+ DatabaseSchema(provider).initialize()
+ db = Database(_db_path)
+ peer = "a" * 32
+ msg = "b" * 32
+ _insert_failed(db, msg_hash=msg, peer=peer, content="hello")
+ now = time.time()
+ assert db.messages.try_claim_failed_message_for_auto_resend(
+ msg,
+ cooldown_until=now + 120,
+ now=now,
+ )
+ assert not db.messages.try_claim_failed_message_for_auto_resend(
+ msg,
+ cooldown_until=now + 240,
+ now=now,
+ )
+ db.close_all()
+ provider.close_all()
+
+
+def test_recent_outbound_same_content_blocks_duplicate(_db_path):
+ provider = DatabaseProvider(_db_path)
+ DatabaseSchema(provider).initialize()
+ db = Database(_db_path)
+ peer = "c" * 32
+ now = time.time()
+ db.messages.upsert_lxmf_message(
+ {
+ "hash": "d" * 32,
+ "source_hash": peer,
+ "destination_hash": peer,
+ "peer_hash": peer,
+ "state": "delivered",
+ "progress": 1,
+ "is_incoming": 0,
+ "method": "direct",
+ "delivery_attempts": 1,
+ "next_delivery_attempt_at": None,
+ "title": "",
+ "content": "same body",
+ "fields": "{}",
+ "rssi": None,
+ "snr": None,
+ "quality": None,
+ "is_spam": 0,
+ "reply_to_hash": None,
+ "attachments_stripped": 0,
+ "timestamp": now - 10,
+ },
+ )
+ assert db.messages.has_recent_outbound_with_content(
+ peer,
+ "same body",
+ within_seconds=300,
+ now=now,
+ )
+ assert not db.messages.has_recent_outbound_with_content(
+ peer,
+ "other",
+ within_seconds=300,
+ now=now,
+ )
+ db.close_all()
+ provider.close_all()
+
+
+@pytest.mark.asyncio
+async def test_resend_uses_lock_and_skips_second_concurrent_claim(_db_path):
+ provider = DatabaseProvider(_db_path)
+ DatabaseSchema(provider).initialize()
+ db = Database(_db_path)
+ peer = "e" * 32
+ msg = "f" * 32
+ _insert_failed(db, msg_hash=msg, peer=peer, content="dup text")
+
+ app = MagicMock()
+ app._auto_resend_coordinator = guard.AutoResendCoordinator()
+ app.websocket_broadcast = AsyncMock()
+
+ ctx = MagicMock()
+ ctx.identity.hash.hex.return_value = "11" * 16
+ ctx.database = db
+ ctx.config.allow_auto_resending_failed_messages_with_attachments.get.return_value = True
+
+ send_calls = []
+
+ async def fake_send(*args, **kwargs):
+ send_calls.append(1)
+ await asyncio.sleep(0.05)
+ m = MagicMock()
+ m.hash = bytes.fromhex("aa" * 16)
+ return m
+
+ # Bind the real method implementation by importing MeshChat is heavy.
+ # Exercise coordinator + claim path directly to prove race safety.
+ lock = app._auto_resend_coordinator.lock_for("id1", peer)
+
+ async def claim_once():
+ async with lock:
+ now = time.time()
+ claimed = db.messages.try_claim_failed_message_for_auto_resend(
+ msg,
+ cooldown_until=now + 120,
+ now=now,
+ )
+ if claimed:
+ await fake_send()
+ return claimed
+
+ results = await asyncio.gather(claim_once(), claim_once())
+ assert sorted(results) == [False, True]
+ assert len(send_calls) == 1
+ db.close_all()
+ provider.close_all()
+
+
+def test_set_auto_resend_count_merges_fields(_db_path):
+ provider = DatabaseProvider(_db_path)
+ DatabaseSchema(provider).initialize()
+ db = Database(_db_path)
+ peer = "1" * 32
+ msg = "2" * 32
+ _insert_failed(
+ db,
+ msg_hash=msg,
+ peer=peer,
+ content="x",
+ fields='{"keep":true}',
+ )
+ db.messages.set_auto_resend_count_on_message(msg, 2)
+ row = db.messages.provider.fetchone(
+ "SELECT fields FROM lxmf_messages WHERE hash = ?",
+ (msg,),
+ )
+ assert '"keep": true' in row["fields"] or '"keep":true' in row["fields"]
+ assert guard.read_auto_resend_count(row["fields"]) == 2
+ db.close_all()
+ provider.close_all()
diff --git a/tests/backend/test_duplicate_messages_cleanup.py b/tests/backend/test_duplicate_messages_cleanup.py
new file mode 100644
index 00000000..708c502c
--- /dev/null
+++ b/tests/backend/test_duplicate_messages_cleanup.py
@@ -0,0 +1,80 @@
+# SPDX-License-Identifier: 0BSD
+
+import time
+
+from meshchatx.src.backend.database import Database
+from meshchatx.src.backend.database.provider import DatabaseProvider
+from meshchatx.src.backend.database.schema import DatabaseSchema
+
+
+def _msg(db, *, msg_hash, peer, content, is_incoming=1, ts=None):
+ now = ts if ts is not None else time.time()
+ db.messages.upsert_lxmf_message(
+ {
+ "hash": msg_hash,
+ "source_hash": peer,
+ "destination_hash": peer,
+ "peer_hash": peer,
+ "state": "delivered",
+ "progress": 1.0,
+ "is_incoming": is_incoming,
+ "method": "direct",
+ "delivery_attempts": 1,
+ "next_delivery_attempt_at": None,
+ "title": "",
+ "content": content,
+ "fields": "{}",
+ "rssi": None,
+ "snr": None,
+ "quality": None,
+ "is_spam": 0,
+ "reply_to_hash": None,
+ "attachments_stripped": 0,
+ "timestamp": now,
+ },
+ )
+
+
+def test_delete_duplicate_messages_by_content_keeps_oldest(tmp_path):
+ path = str(tmp_path / "d.db")
+ provider = DatabaseProvider(path)
+ DatabaseSchema(provider).initialize()
+ db = Database(path)
+ peer = "a" * 32
+ base = time.time()
+ _msg(db, msg_hash="1" * 32, peer=peer, content="hello", ts=base - 30)
+ _msg(db, msg_hash="2" * 32, peer=peer, content="hello", ts=base - 20)
+ _msg(db, msg_hash="3" * 32, peer=peer, content="hello", ts=base - 10)
+ _msg(db, msg_hash="4" * 32, peer=peer, content="other", ts=base)
+ _msg(db, msg_hash="5" * 32, peer=peer, content="", ts=base)
+ _msg(db, msg_hash="6" * 32, peer=peer, content="", ts=base + 1)
+
+ assert db.messages.count_duplicate_lxmf_messages_by_content() == 2
+ deleted = db.messages.delete_duplicate_lxmf_messages_by_content()
+ assert deleted == 2
+ assert db.messages.count_lxmf_messages() == 4
+ remaining = {
+ r["hash"]
+ for r in db.provider.fetchall("SELECT hash, content FROM lxmf_messages")
+ }
+ assert "1" * 32 in remaining
+ assert "4" * 32 in remaining
+ assert "2" * 32 not in remaining
+ assert "3" * 32 not in remaining
+ db.close_all()
+ provider.close_all()
+
+
+def test_duplicates_are_scoped_by_peer_and_direction(tmp_path):
+ path = str(tmp_path / "d2.db")
+ provider = DatabaseProvider(path)
+ DatabaseSchema(provider).initialize()
+ db = Database(path)
+ peer_a = "b" * 32
+ peer_b = "c" * 32
+ _msg(db, msg_hash="1" * 32, peer=peer_a, content="same", is_incoming=1)
+ _msg(db, msg_hash="2" * 32, peer=peer_b, content="same", is_incoming=1)
+ _msg(db, msg_hash="3" * 32, peer=peer_a, content="same", is_incoming=0)
+ assert db.messages.count_duplicate_lxmf_messages_by_content() == 0
+ db.close_all()
+ provider.close_all()
diff --git a/tests/backend/test_local_message_retention.py b/tests/backend/test_local_message_retention.py
index dc9504ef..6d687e61 100644
--- a/tests/backend/test_local_message_retention.py
+++ b/tests/backend/test_local_message_retention.py
@@ -218,3 +218,78 @@ def test_apply_calls_cancel_for_hex_hashes(_db_path):
assert db.messages.count_lxmf_messages() == 0
db.close_all()
provider.close_all()
+
+
+def test_parse_before_cutoff_date_and_unix():
+ assert lmr.parse_before_cutoff("1705276800") == 1705276800.0
+ assert lmr.parse_before_cutoff("2024-01-15") == 1705276800.0
+ try:
+ lmr.parse_before_cutoff("not-a-date")
+ raise AssertionError("expected ValueError")
+ except ValueError:
+ pass
+
+
+def test_resolve_message_age_cutoff_prefers_before():
+ now = 2_000_000.0
+ assert (
+ lmr.resolve_message_age_cutoff(older_than_days=10, now=now) == now - 10 * 86400
+ )
+ assert (
+ lmr.resolve_message_age_cutoff(
+ older_than_days=10,
+ before="2024-01-15",
+ now=now,
+ )
+ == 1705276800.0
+ )
+ assert lmr.resolve_message_age_cutoff() is None
+ try:
+ lmr.resolve_message_age_cutoff(older_than_days=0)
+ raise AssertionError("expected ValueError")
+ except ValueError:
+ pass
+
+
+def test_purge_messages_before_cutoff_and_count(_db_path):
+ provider = DatabaseProvider(_db_path)
+ DatabaseSchema(provider).initialize()
+ db = Database(_db_path)
+ now = time.time()
+ peer = "c" * 32
+ base = {
+ "source_hash": peer,
+ "destination_hash": peer,
+ "peer_hash": peer,
+ "state": "delivered",
+ "progress": 1.0,
+ "is_incoming": 1,
+ "method": "ephemeral",
+ "delivery_attempts": 0,
+ "next_delivery_attempt_at": None,
+ "title": "t",
+ "content": "c",
+ "fields": '{"file_attachments":[{"name":"a.bin"}]}',
+ "rssi": None,
+ "snr": None,
+ "quality": None,
+ "is_spam": 0,
+ "reply_to_hash": None,
+ "attachments_stripped": 0,
+ }
+ db.messages.upsert_lxmf_message(
+ {**base, "hash": "d" * 32, "timestamp": now - 40 * 86400}
+ )
+ db.messages.upsert_lxmf_message(
+ {**base, "hash": "e" * 32, "timestamp": now - 5 * 86400}
+ )
+ cutoff = now - 30 * 86400
+ assert db.messages.count_lxmf_messages_with_timestamp_before(cutoff) == 1
+ rows = db.messages.get_lxmf_messages_with_timestamp_before(cutoff)
+ assert len(rows) == 1
+ assert rows[0]["hash"] == "d" * 32
+ deleted = lmr.purge_messages_before_cutoff(db.messages, None, cutoff)
+ assert deleted == 1
+ assert db.messages.count_lxmf_messages() == 1
+ db.close_all()
+ provider.close_all()
diff --git a/tests/frontend/SettingsPage.config-persistence.test.js b/tests/frontend/SettingsPage.config-persistence.test.js
index 89b018ae..4d9993bd 100644
--- a/tests/frontend/SettingsPage.config-persistence.test.js
+++ b/tests/frontend/SettingsPage.config-persistence.test.js
@@ -604,6 +604,13 @@ describe("SettingsPage — maintenance, exports, telemetry trust, RNS reload", (
expect(api.delete).toHaveBeenCalledWith("/api/v1/maintenance/messages");
});
+ it("clearDuplicateMessages DELETEs duplicates endpoint", async () => {
+ const w = await mountSettingsPage(api);
+ api.delete.mockResolvedValue({ data: { deleted: 5 } });
+ await w.vm.clearDuplicateMessages();
+ expect(api.delete).toHaveBeenCalledWith("/api/v1/maintenance/messages/duplicates");
+ });
+
it("clearAnnounces DELETEs announces", async () => {
const w = await mountSettingsPage(api);
await w.vm.clearAnnounces();
@@ -645,7 +652,41 @@ describe("SettingsPage — maintenance, exports, telemetry trust, RNS reload", (
it("exportMessages GETs export endpoint", async () => {
const w = await mountSettingsPage(api);
await w.vm.exportMessages();
- expect(api.get).toHaveBeenCalledWith("/api/v1/maintenance/messages/export");
+ expect(api.get).toHaveBeenCalledWith("/api/v1/maintenance/messages/export", undefined);
+ });
+
+ it("purgeOldMessages DELETEs with older_than_days", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.messageAgePurgeMode = "days";
+ w.vm.messageAgePurgeDays = 30;
+ api.delete.mockResolvedValue({ data: { deleted: 2 } });
+ await w.vm.purgeOldMessages();
+ expect(api.delete).toHaveBeenCalledWith("/api/v1/maintenance/messages", {
+ params: { older_than_days: 30 },
+ });
+ });
+
+ it("refreshMessageAgePurgePreview GETs purge-preview", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.messageAgePurgeMode = "date";
+ w.vm.messageAgePurgeBeforeDate = "2024-01-15";
+ api.get.mockResolvedValue({ data: { count: 4, cutoff: 1 } });
+ await w.vm.refreshMessageAgePurgePreview();
+ expect(api.get).toHaveBeenCalledWith("/api/v1/maintenance/messages/purge-preview", {
+ params: { before: "2024-01-15" },
+ });
+ expect(w.vm.messageAgePurgePreviewCount).toBe(4);
+ });
+
+ it("exportOldMessagesArchive GETs filtered export", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.messageAgePurgeMode = "days";
+ w.vm.messageAgePurgeDays = 90;
+ api.get.mockResolvedValue({ data: { format: "meshchatx/messages/v2", messages: [] } });
+ await w.vm.exportOldMessagesArchive();
+ expect(api.get).toHaveBeenCalledWith("/api/v1/maintenance/messages/export", {
+ params: { older_than_days: 90 },
+ });
});
it("exportFolders GETs folders export", async () => {
diff --git a/tests/frontend/fixtures/settingsPageTestApi.js b/tests/frontend/fixtures/settingsPageTestApi.js
index 509b7849..e272f97b 100644
--- a/tests/frontend/fixtures/settingsPageTestApi.js
+++ b/tests/frontend/fixtures/settingsPageTestApi.js
@@ -158,6 +158,9 @@ export function createWindowApi(serverConfigRef) {
if (String(url).includes("/api/v1/stickers") && !String(url).includes("export")) {
return Promise.resolve({ data: { stickers: [] } });
}
+ if (String(url).includes("/api/v1/maintenance/messages/purge-preview")) {
+ return Promise.resolve({ data: { count: 0, cutoff: 1 } });
+ }
if (String(url).includes("/api/v1/maintenance/messages/export")) {
return Promise.resolve({ data: { messages: [] } });
}
diff --git a/tests/frontend/settingsMaintenanceClient.test.js b/tests/frontend/settingsMaintenanceClient.test.js
new file mode 100644
index 00000000..abbe854d
--- /dev/null
+++ b/tests/frontend/settingsMaintenanceClient.test.js
@@ -0,0 +1,66 @@
+import { describe, it, expect, vi } from "vitest";
+import {
+ buildMessageAgeFilterParams,
+ previewMessageAgePurge,
+ purgeMessagesByAge,
+ exportMessagesBundle,
+ previewDuplicateMessages,
+ clearDuplicateMessages,
+} from "@/js/settings/settingsMaintenanceClient.js";
+
+describe("settingsMaintenanceClient message age helpers", () => {
+ it("buildMessageAgeFilterParams validates days and date modes", () => {
+ expect(buildMessageAgeFilterParams({ mode: "days", days: 90 })).toEqual({
+ older_than_days: 90,
+ });
+ expect(buildMessageAgeFilterParams({ mode: "days", days: 0 })).toBeNull();
+ expect(buildMessageAgeFilterParams({ mode: "date", beforeDate: "2024-06-01" })).toEqual({
+ before: "2024-06-01",
+ });
+ expect(buildMessageAgeFilterParams({ mode: "date", beforeDate: "nope" })).toBeNull();
+ });
+
+ it("previewMessageAgePurge and purgeMessagesByAge hit the right endpoints", async () => {
+ const api = {
+ get: vi.fn().mockResolvedValue({ data: { count: 3, cutoff: 10 } }),
+ delete: vi.fn().mockResolvedValue({ data: { deleted: 3, cutoff: 10 } }),
+ };
+ await expect(previewMessageAgePurge(api, { older_than_days: 7 })).resolves.toEqual({
+ count: 3,
+ cutoff: 10,
+ });
+ expect(api.get).toHaveBeenCalledWith("/api/v1/maintenance/messages/purge-preview", {
+ params: { older_than_days: 7 },
+ });
+ await expect(purgeMessagesByAge(api, { before: "2024-01-01" })).resolves.toEqual({
+ deleted: 3,
+ cutoff: 10,
+ });
+ expect(api.delete).toHaveBeenCalledWith("/api/v1/maintenance/messages", {
+ params: { before: "2024-01-01" },
+ });
+ });
+
+ it("previewDuplicateMessages and clearDuplicateMessages hit endpoints", async () => {
+ const api = {
+ get: vi.fn().mockResolvedValue({ data: { count: 7 } }),
+ delete: vi.fn().mockResolvedValue({ data: { deleted: 7 } }),
+ };
+ await expect(previewDuplicateMessages(api)).resolves.toEqual({ count: 7 });
+ expect(api.get).toHaveBeenCalledWith("/api/v1/maintenance/messages/duplicates");
+ await expect(clearDuplicateMessages(api)).resolves.toEqual({ deleted: 7 });
+ expect(api.delete).toHaveBeenCalledWith("/api/v1/maintenance/messages/duplicates");
+ });
+
+ it("exportMessagesBundle passes optional filter params", async () => {
+ const api = {
+ get: vi.fn().mockResolvedValue({ data: { messages: [] } }),
+ };
+ await exportMessagesBundle(api);
+ expect(api.get).toHaveBeenCalledWith("/api/v1/maintenance/messages/export", undefined);
+ await exportMessagesBundle(api, { older_than_days: 14 });
+ expect(api.get).toHaveBeenCalledWith("/api/v1/maintenance/messages/export", {
+ params: { older_than_days: 14 },
+ });
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────